Анализ поведения пользователей мобильного приложения

Оглавление

Описание проекта

Стартап продаёт продукты питания. Дизайнеры предложили поменять шрифты во всём приложении, но менеджеры считают, что пользователям будет непривычно. Нужно разобраться, как ведут себя пользователи мобильного приложения, а также по результатам A/A/B-теста принять решение, какой шрифт лучше.

Цель и задачи проекта

Цель проекта - подготовка рекомендаций по целесообразности изменения шрифтов в мобильном приложении на основе изучения поведения его пользователей и результатов А/A/B-теста.
Для этого необходимо изучить:

  1. Данные:
    • посчитать, сколько всего событий в логе;
    • посчитать, сколько всего пользователей в логе;
    • посчитать, сколько в среднем событий приходится на пользователя;
    • определить, данными за какой период мы располагаем, найти максимальную и минимальную дату;
    • установить, одинаково ли полные данные за весь период, определить, с какого момента данные полные и отбросить более старые;
    • определить, данными за какой период времени мы располагаем на самом деле, много ли событий и пользователей было потеряно, отбросив старые данные;
    • проверить, имеются ли пользователи из всех трёх экспериментальных групп.

  2. Воронку событий:
    • определить, какие события есть в логах, как часто они встречаются, отсортировать события по частоте;
    • посчитать, сколько пользователей совершали каждое из этих событий, отсортировать события по числу пользователей, посчитать долю пользователей, которые хоть раз совершали событие;
    • предположить, в каком порядке происходят события, определить, все ли они выстраиваются в последовательную цепочку;
    • посчитать по воронке событий, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем);
    • определить, на каком шаге теряется больше всего пользователей;
    • определить, какая доля пользователей доходит от первого события до оплаты.

  3. Результаты эксперимента:
    • посчитать,сколько пользователей в каждой контрольной и экспериментальной группе;
    • проверить, находят ли статистические критерии разницу между контрольными группами;
    • выбрать самое популярное событие, посчитать число пользователей, совершивших это событие в каждой из контрольных групп, посчитать долю пользователей, совершивших это событие, проверить, будет ли отличие между группами статистически достоверным;
    • совершить аналогичные действия для всех других событий, определить, корректно ли работает разбиение на группы;
    • совершить аналогичные действия с экспериментальной группой, сравнить результаты с каждой из контрольных групп в отдельности по каждому событию, а также с объединённой контрольной группой, сделать выводы из проведенного эксперимента;
    • посчитать, сколько проверок статистических гипотез было сделано;
    • установить, какой уровень значимости стоит применить, определить целесообразность его изменения, в случае изменения уровня значимости, проделать предыдущие шаги и проверить сделанные выводы.

Описание данных

Каждая запись в логе — это действие пользователя, или событие:

  • EventName - название события;
  • DeviceIDHash - уникальный идентификатор пользователя;
  • EventTimestamp - время события;
  • ExpId - номер эксперимента: 246 и 247 - контрольные группы, а 248 - экспериментальная.

Открытие данных и изучение общей информации

In [1]:
# импортируем необходимые библиотеки
import pandas as pd
import numpy as np
import scipy.stats as stats
import math as mth
import plotly
import plotly.express as px 
from plotly import graph_objects as go
import plotly.io as pio
pio.templates.default = 'seaborn'
In [2]:
# %%HTML
# <style type="text/css">
# table.dataframe td, table.dataframe th {
#     border: 1px  black solid !important;
#   color: black !important;
# }
In [3]:
# прочитаем DataFrame
try:
    df = pd.read_csv('logs_exp.csv', sep = '\t')  # локальный путь
except:
    df = pd.read_csv('/datasets/logs_exp.csv', sep = '\t')  # путь на сервере
In [4]:
# выведем на экран 10 верхних строк таблицы
df.head(10)
Out[4]:
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
5 CartScreenAppear 6217807653094995999 1564055323 248
6 OffersScreenAppear 8351860793733343758 1564066242 246
7 MainScreenAppear 5682100281902512875 1564085677 246
8 MainScreenAppear 1850981295691852772 1564086702 247
9 MainScreenAppear 5407636962369102641 1564112112 246
In [5]:
# посмотрим сводную информацию таблицы
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB

В таблице 244126 строк, 4 столбца, тип данных у одного столбца строковый, у остальных - целочисленный. Для удобства необходимо заменить названия столбцов, а также значения в столбце с названиями событий. В столбце EventTimestamp значения времени события указаны в формате unix time.

In [6]:
# посмотрим количество уникальных значений по столбцам таблицы
for column in df.columns:
    print(column)
    print()
    print(df[column].value_counts())
    print()        
EventName

MainScreenAppear           119205
OffersScreenAppear          46825
CartScreenAppear            42731
PaymentScreenSuccessful     34313
Tutorial                     1052
Name: EventName, dtype: int64

DeviceIDHash

6304868067479728361    2308
197027893265565660     2003
4623191541214045580    1771
6932517045703054087    1448
1754140665440434215    1222
                       ... 
7724520246123323531       1
2760145394827990211       1
2086627244641656064       1
8164821368561674670       1
1083512226259476085       1
Name: DeviceIDHash, Length: 7551, dtype: int64

EventTimestamp

1564935799    9
1564670435    9
1565017227    8
1565191469    8
1564911846    8
             ..
1564717756    1
1564758720    1
1564766916    1
1564738250    1
1565191461    1
Name: EventTimestamp, Length: 176654, dtype: int64

ExpId

248    85747
246    80304
247    78075
Name: ExpId, dtype: int64

Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы (119205 раз), наименьшее - посещение страницы с инструкцией по работе приложения (1052 раза). Действия осуществлялись 7551 уникальным пользователем, максимальное количество действий, совершенных одним пользователем - 2308. Действия совершались пользователями трех различных групп, наибольшее их количество произведено пользователями группы 248 (экспериментальной группы) - 85747.

In [7]:
# определим период информации в таблице
round((df['EventTimestamp'].max() - df['EventTimestamp'].min()) / 86400, 0)
Out[7]:
14.0

В таблице представлена информация за 14 дней.

In [8]:
# определим количество пропущенных значений в таблице
df.isnull().sum()
Out[8]:
EventName         0
DeviceIDHash      0
EventTimestamp    0
ExpId             0
dtype: int64

Пропущенные значения отсутствуют.

In [9]:
# посчитаем количество дубликатов
df.duplicated().sum()
Out[9]:
413

Выявлено 413 повторяющихся строк.

In [10]:
# посчитаем долю дубликатов в таблице
round(df.duplicated().sum() / len(df) * 100, 2)
Out[10]:
0.17

Повторяющихся строк - 0,17%.

Вывод

При изучении таблицы с данными установлено следующее:

  • в таблице 244126 строк, 4 столбца, пропущенные значения отсутствуют;
  • тип данных у одного столбца строковый, у остальных - целочисленный;
  • для удобства необходимо заменить названия столбцов, а также значения в столбце с названиями событий;
  • в столбце EventTimestamp значения времени события указаны в формате unix time;
  • в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы (119205 раз), наименьшее - посещение страницы с инструкцией по работе приложения (1052 раза);
  • действия осуществлялись 7551 уникальным пользователем, максимальное количество действий, совершенных одним пользователем - 2308;
  • действия совершались пользователями трех различных групп, наибольшее их количество произведено пользователями группы 248 (экспериментальной группы) - 85747;
  • в таблице представлена информация за 14 дней;
  • выявлено 413 дубликатов (0,17% всех строк).

Проанализировав вышеизложенное, необходимо выполнить следующее:

  1. заменить названия столбцов, а также значения в столбце EventName с названиями событий;
  2. значения времени события в столбце EventTimestamp привести к формату datetime;
  3. удалить дубликаты.

Подготовка данных

In [11]:
# заменим названия столбцов
df.columns = ['event_name',
              'user_id',
              'event_time',
              'group']
df.head()
Out[11]:
event_name user_id event_time group
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
In [12]:
# заменим значения в столбце с названиями событий
df['event_name'] = df['event_name'].str.replace('MainScreenAppear', 'main_screen')
df['event_name'] = df['event_name'].str.replace('OffersScreenAppear', 'offer_screen')
df['event_name'] = df['event_name'].str.replace('CartScreenAppear', 'cart_screen')
df['event_name'] = df['event_name'].str.replace('PaymentScreenSuccessful', 'payment_screen')
df['event_name'] = df['event_name'].str.replace('Tutorial', 'tutorial')
df['event_name'].unique()
Out[12]:
array(['main_screen', 'payment_screen', 'cart_screen', 'offer_screen',
       'tutorial'], dtype=object)
In [13]:
# приведем значения времени события к формату "datetime"
df['event_time'] = pd.to_datetime(df['event_time'],
                                  unit = 's')
df.head()
Out[13]:
event_name user_id event_time group
0 main_screen 4575588528974610257 2019-07-25 04:43:36 246
1 main_screen 7416695313311560658 2019-07-25 11:11:42 246
2 payment_screen 3518123091307005509 2019-07-25 11:28:47 248
3 cart_screen 3518123091307005509 2019-07-25 11:28:47 248
4 payment_screen 6217807653094995999 2019-07-25 11:48:42 248
In [14]:
# добавим в таблицу столбец с датой события
df['event_date'] = df['event_time'].astype('datetime64[D]')
df.head()
Out[14]:
event_name user_id event_time group event_date
0 main_screen 4575588528974610257 2019-07-25 04:43:36 246 2019-07-25
1 main_screen 7416695313311560658 2019-07-25 11:11:42 246 2019-07-25
2 payment_screen 3518123091307005509 2019-07-25 11:28:47 248 2019-07-25
3 cart_screen 3518123091307005509 2019-07-25 11:28:47 248 2019-07-25
4 payment_screen 6217807653094995999 2019-07-25 11:48:42 248 2019-07-25
In [15]:
# удалим дубликаты
df = df.drop_duplicates()\
       .reset_index(drop = True)
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 243713 entries, 0 to 243712
Data columns (total 5 columns):
 #   Column      Non-Null Count   Dtype         
---  ------      --------------   -----         
 0   event_name  243713 non-null  object        
 1   user_id     243713 non-null  int64         
 2   event_time  243713 non-null  datetime64[ns]
 3   group       243713 non-null  int64         
 4   event_date  243713 non-null  datetime64[ns]
dtypes: datetime64[ns](2), int64(2), object(1)
memory usage: 9.3+ MB

Вывод

В целях подготовки данных провели следующую работу:

  1. заменили названия столбцов, а также значения в столбце с названиями событий;
  2. значения времени события привели к формату datetime;
  3. добавили столбец с датой события;
  4. удалили дубликаты.

Исследование данных

Изучение и проверка данных

Cколько всего событий в логе?

In [16]:
# определим количество событий
df['event_name'].count()
Out[16]:
243713

Всего в логе 243713 событий.

Cколько всего пользователей в логе?

In [17]:
# определим количество уникальных пользователей
df['user_id'].nunique()
Out[17]:
7551

Всего в логе 7551 уникальный пользователь.

Cколько в среднем событий приходится на пользователя?

In [18]:
# создадим таблицу с количеством событий в логе по пользователям
event_users = df.groupby('user_id')\
                .agg(event_count = ('event_name', 'count'))\
                .reset_index()                     
event_users.head()
Out[18]:
user_id event_count
0 6888746892508752 1
1 6909561520679493 5
2 6922444491712477 47
3 7435777799948366 6
4 7702139951469979 137
In [19]:
# изучим статистическую информацию количества событий по пользователям
event_users['event_count'].describe()
Out[19]:
count    7551.000000
mean       32.275593
std        65.154219
min         1.000000
25%         9.000000
50%        20.000000
75%        37.000000
max      2307.000000
Name: event_count, dtype: float64

Среднее арифметическое количества событий на одного пользователя - 32, медиана - 20. Наблюдается очень большой разброс этого показателя: минимальное количество событий на одного пользователя - 1, максимальное - 2307.

In [20]:
# построим график распределения количества событий по числу пользователей
fig = px.histogram(event_users,
                   x = 'event_count',
                   marginal = 'box')
fig.update_layout(title = 'Распределение количества событий по числу пользователей',
                  xaxis_title = 'Количество событий',
                  yaxis_title = 'Количество пользователей',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Событий: %{x}<br>Пользователей: %{y}')
fig.show()

Так как распределение не является нормальным, имеется большое количество аномальных значений (выбросов), в качестве среднего целесообразнее использовать медиану.

Таким образом, в среднем на одного пользователя приходится 20 событий.

Данными за какой период мы располагаем?

In [21]:
# определим, за какой период мы располагаем данными
df['event_date'].astype('str').unique()
Out[21]:
array(['2019-07-25', '2019-07-26', '2019-07-27', '2019-07-28',
       '2019-07-29', '2019-07-30', '2019-07-31', '2019-08-01',
       '2019-08-02', '2019-08-03', '2019-08-04', '2019-08-05',
       '2019-08-06', '2019-08-07'], dtype=object)

Мы располагаем данными за период с 25 июля по 7 августа 2019 года (14 дней).

In [22]:
# определим минимальные и максимальные время и дату событий
df['event_time'].min(), df['event_time'].max()
Out[22]:
(Timestamp('2019-07-25 04:43:36'), Timestamp('2019-08-07 21:15:17'))

Минимальные дата и время события - 25 июля 2019 года 4 часа 43 минуты 36 секунд, максимальные - 7 августа 2019 года 21 час 15 минут 17 секунд.

Одинаково ли полные данные за весь период?

In [23]:
# построим график распределения количества событий по времени
fig = px.histogram(df,
                   x = 'event_time')
fig.update_layout(title = 'Распределение количества событий по времени',
                  xaxis = dict(title = 'Дата',
                               tickformat = '%d.%m',
                               hoverformat = '%H.%M'),
                  yaxis_title = 'Количество событий',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Время: %{x}<br>Событий: %{y}')
fig.show()

Исходя из графика распределения заметно, что у нас имеются неполные данные до 31 июля 2019 года включительно. Вероятнее всего, подобный "перекос" данных связан с тем, в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого. Таким образом, для анализа целесообразно использовать данные с 1 августа 2019 года. Вместе с тем, на графике видно, что справа (в конце анализируемого периода) отсутствуют данные за несколько часов 7 августа 2019 года. Это можно исправить, удалив данные за этот день, но поскольку их часть и так необходимо исключить из анализа, 7 августа 2019 года оставим в анализируемом периоде.

Данными за какой период времени мы располагаем на самом деле?

Таким образом актуальный период для исследования - с 1 по 7 августа 2019 года (7 дней).

In [24]:
# удалим из таблицы данные за неактуальный период
df_new = df.query('event_date >= "2019-08-01"')\
           .reset_index(drop = True)
df_new.head()
Out[24]:
event_name user_id event_time group event_date
0 tutorial 3737462046622621720 2019-08-01 00:07:28 246 2019-08-01
1 main_screen 3737462046622621720 2019-08-01 00:08:00 246 2019-08-01
2 main_screen 3737462046622621720 2019-08-01 00:08:55 246 2019-08-01
3 offer_screen 3737462046622621720 2019-08-01 00:08:58 246 2019-08-01
4 main_screen 1433840883824088890 2019-08-01 00:08:59 247 2019-08-01

Проверим, есть ли пользователи, которые воспользовались приложением, минуя его главную страницу.

In [25]:
# создадим таблицу с количеством событий по их видам по пользователям 
main_screen_isna = df_new.pivot_table(index = 'user_id',
                                      columns = 'event_name',
                                      values = 'event_time',
                                      aggfunc = 'count')\
                          .reset_index()
main_screen_isna.head()
Out[25]:
event_name user_id cart_screen main_screen offer_screen payment_screen tutorial
0 6888746892508752 NaN 1.0 NaN NaN NaN
1 6909561520679493 1.0 2.0 1.0 1.0 NaN
2 6922444491712477 8.0 19.0 12.0 8.0 NaN
3 7435777799948366 NaN 6.0 NaN NaN NaN
4 7702139951469979 5.0 40.0 87.0 5.0 NaN
In [26]:
# посчитаем количество пользователей, которые воспользовались приложением, минуя его главную страницу
main_screen_isna[main_screen_isna['main_screen'].isna()]['user_id'].count()
Out[26]:
115

115 пользователей воспользовались приложением, минуя его главную страницу.

In [27]:
# создадим список с пользователями, которые воспользовались приложением, минуя его главную страницу 
main_screen_isna_list = main_screen_isna[main_screen_isna['main_screen'].isna()]['user_id'].to_list()
main_screen_isna_list
Out[27]:
[74158328448226259,
 111394506613435756,
 214966247576341063,
 261817378841141406,
 332529825412858125,
 342904511578898557,
 377428696293052237,
 448788733913496064,
 518781617060869985,
 652742507219479167,
 769261358925388390,
 1074933668878523996,
 1223708690315846789,
 1302351908905417113,
 1388226860960866025,
 1478347681767261393,
 1507659253521619510,
 1818888580792312774,
 1828527697663147220,
 1900791869709139147,
 1958496982439584534,
 2178857088968433184,
 2207627660352986049,
 2254073990000595291,
 2427083753790244053,
 2472435690120708424,
 2485641541735752193,
 2525867977967066505,
 2558632398835985763,
 2659616611828697302,
 2854953699233970068,
 2858354782162067969,
 2890852187923418999,
 2896702740085057040,
 2974131200515436842,
 3003444229104601988,
 3187166762535343300,
 3281990133278483279,
 3609155040662138444,
 3694869469012370791,
 3783993475053044166,
 3788810404193836212,
 3789748391257499056,
 3800345857856674685,
 3889455123497664910,
 3993679963698158149,
 4043451913990355083,
 4104516806157656680,
 4111480625662873403,
 4117244457378452102,
 4118486435138555606,
 4191814992147913536,
 4195154564677094093,
 4233446822747848192,
 4297538850737525775,
 4477345761003558517,
 4497790242237033881,
 4530866328707864508,
 4549369623544986227,
 4598908767972224335,
 4753277292482557629,
 4801856899146111374,
 4980045449118619005,
 4982049164671261616,
 4989665298201075580,
 4989781064655649615,
 5123924084279895785,
 5322240575085479425,
 5386777428310670499,
 5486948880501721254,
 5691320000211237747,
 5741445112252675099,
 5877487109553929606,
 5927183327273225970,
 5982189655096173390,
 5984236888994107050,
 6037447991253299575,
 6054246013759836289,
 6158525878411557755,
 6452297315217439503,
 6504780035871286715,
 6589262034654130848,
 6653592794823360382,
 6713585033038190986,
 6845357864194750572,
 6921873124105213121,
 7095529379893861485,
 7225065237801313955,
 7267296482767040558,
 7275199030295931978,
 7356545191453403931,
 7395878307424700044,
 7434898433092781323,
 7618654849956768852,
 7716166848354963661,
 7759046825502050643,
 7845716119528742493,
 7914773194539807942,
 7957507168530623831,
 8005753485514903385,
 8043544466477493917,
 8092568022639166769,
 8269382316119745828,
 8364569655761628358,
 8377201126969110825,
 8379429431611543492,
 8407622027339743528,
 8416803071325524257,
 8543559760064270249,
 8561578676087325759,
 8586953157808767383,
 8804319115517716344,
 8821171531680573201,
 9124766629178994679,
 9217594193087726423]
In [28]:
# создадим таблицу с количеством событий и пользователей, которые воспользовались приложением, минуя его главную страницу,
# по видам событий
df_new.query('user_id == @main_screen_isna_list').groupby('event_name')\
                                                 .agg(event_count = ('event_time', 'count'),
                                                      user_count = ('user_id', 'nunique'))\
                                                 .sort_values(by = 'user_count',
                                                              ascending = False)\
                                                 .reset_index()
Out[28]:
event_name event_count user_count
0 offer_screen 948 111
1 cart_screen 909 99
2 payment_screen 747 98
3 tutorial 7 4

Из 115 пользователей, которые воспользовались приложением, минуя его главную страницу, 111 сразу посетили страницу с предложениями товара, а 4 - страницу с инструкцией по работе приложения.

In [29]:
# создадим таблицу с количеством событий и пользователей, которые воспользовались приложением, минуя его главную страницу,
# по группам
df_new.query('user_id == @main_screen_isna_list').groupby('group')\
                                                 .agg(event_count = ('event_time', 'count'),
                                                      user_count = ('user_id', 'nunique'))\
                                                 .reset_index()
Out[29]:
group event_count user_count
0 246 806 34
1 247 874 37
2 248 931 44
In [30]:
# построим график распределения количества событий, совершенных пользователями, которые воспользовались приложением,
# минуя его главную страницу, по времени
fig = px.histogram(df_new.query('user_id == @main_screen_isna_list'),
                   x = 'event_time')
fig.update_layout(title = 'Распределение количества событий по времени',
                  xaxis = dict(title = 'Дата',
                               tickformat = '%d.%m',
                               hoverformat = '%H.%M'),
                  yaxis_title = 'Количество событий',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Время: %{x}<br>Событий: %{y}')
fig.show()

События, совершенные пользователями, которые воспользовались приложением, минуя его главную страницу, распределены по времени и группам равномерно. Оставим строки с такими пользователями в таблице. Вместе с тем, при построении в дальнейшем воронки событий необходимо учесть, что часть пользователей главную страницу приложения не посещали.

In [31]:
# определим количество событий за актуальный период
df_new['event_name'].count()
Out[31]:
240887
In [32]:
# посчитаем долю "потерянных" событий
round((df['event_name'].count() - df_new['event_name'].count()) / df['event_name'].count() * 100, 2)
Out[32]:
1.16
In [33]:
# определим количество уникальных пользователей за актуальный период
df_new['user_id'].nunique()
Out[33]:
7534
In [34]:
# посчитаем долю "потерянных" пользователей
round((df['user_id'].nunique() - df_new['user_id'].nunique()) / df['user_id'].nunique() * 100, 2)
Out[34]:
0.23

Всего для анализа в актуальном периоде осталось 240887 событий и 7534 уникальных пользователя. Отбросив часть данных, было "потеряно" 1,16% событий и 0,23% пользователей.

Имеются ли пользователи из всех трёх экспериментальных групп?

In [35]:
# создадим таблицу с количеством пользователей в логе по группам по "сырым" данным
event_users_group = df.groupby('group')\
                      .agg(user_count = ('user_id', 'nunique'))\
                      .reset_index()                     
event_users_group
Out[35]:
group user_count
0 246 2489
1 247 2520
2 248 2542
In [36]:
# создадим таблицу с количеством пользователей в логе по группам за актуальный период
event_users_group_new = df_new.groupby('group')\
                              .agg(user_count = ('user_id', 'nunique'))\
                              .reset_index()
event_users_group_new
Out[36]:
group user_count
0 246 2484
1 247 2513
2 248 2537
In [37]:
# построим график количества пользователей в логе по группам
fig = go.Figure()
fig.add_trace(go.Bar(x = event_users_group['group'],
                     y = event_users_group['user_count'],
                     name = 'весь период'))
fig.add_trace(go.Bar(x = event_users_group_new['group'],
                     y = event_users_group_new['user_count'],
                     name = 'актуальный период'))
fig.update_layout(title = 'Количество пользователей в логе по группам',
                  xaxis = dict(title = 'Группа',
                               tickmode = 'array',
                               tickvals = [246, 247, 248]),
                  yaxis_title = 'Количество пользователей',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Группа: %{x}<br>Пользователей: %{y}')
fig.show()

Количество пользователей в контрольных и экспериментальной группах отличается незначительно. Отбросив часть данных, соотношение пользователей по группам изменилось слабо, так как пользователи были исключены равномерно из всех групп.

Вывод

Всего в логе 243713 событий, которые совершил 7551 уникальный пользователь.

Среднее арифметическое количества событий на одного пользователя - 32, медиана - 20. Наблюдается очень большой разброс этого показателя: минимальное количество событий на одного пользователя - 1, максимальное - 2307. Так как распределение количества событий по числу пользователей не является нормальным, имеется большое количество аномальных значений (выбросов), в качестве среднего целесообразнее использовать медиану, которая равна 20.

Всего в логе данные за период с 25 июля по 7 августа 2019 года (14 дней). Минимальные дата и время события - 25 июля 2019 года 4 часа 43 минуты 36 секунд, максимальные - 7 августа 2019 года 21 час 15 минут 17 секунд.

Исходя из графика распределения количества событий по времени заметно, что у нас имеются неполные данные до 31 июля 2019 года включительно. Вероятнее всего, подобный "перекос" данных связан с тем, в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого. Таким образом, для анализа целесообразно использовать данные с 1 августа 2019 года. Вместе с тем, на графике видно, что справа (в конце анализируемого периода) отсутствуют данные за несколько часов 7 августа 2019 года. Это можно исправить, удалив данные за этот день, но поскольку их часть и так необходимо исключить из анализа, 7 августа 2019 года оставили в анализируемом периоде. Таким образом актуальный период для исследования - с 1 по 7 августа 2019 года (7 дней).

115 пользователей воспользовались приложением, минуя его главную страницу, из них 111 сразу посетили страницу с предложениями товара, а 4 - страницу с инструкцией по работе приложения.

События, совершенные пользователями, которые воспользовались приложением, минуя его главную страницу, распределены по времени и группам равномерно, поэтому оставили строки с такими пользователями в таблице.

Всего для анализа в актуальном периоде осталось 240887 событий и 7534 уникальных пользователя. Отбросив часть данных, было "потеряно" 1,16% событий и 0,23% пользователей.

Количество пользователей в контрольных и экспериментальной группах отличается незначительно. Отбросив часть данных, соотношение пользователей по группам изменилось слабо, так как пользователи были исключены равномерно из всех групп.

Изучение воронки событий

Какие события есть в логах, как часто они встречаются?

In [38]:
# создадим таблицу с количеством событий по их видам
event_count = df_new.groupby('event_name')\
                    .agg(event_count = ('user_id', 'count'))\
                    .sort_values(by = 'event_count',
                                 ascending = False)\
                    .reset_index()
event_count
Out[38]:
event_name event_count
0 main_screen 117328
1 offer_screen 46333
2 cart_screen 42303
3 payment_screen 33918
4 tutorial 1005
In [39]:
# построим график количества событий по их видам
fig = px.bar(event_count,
             x = 'event_name',
             y = 'event_count',
             color = 'event_name')
fig.update_layout(title = 'Количество событий по их видам',
                  xaxis_title = 'Вид события',
                  yaxis_title = 'Количество событий',
                  showlegend = False,
                  margin = dict(l = 0, r = 0, t = 70, b = 20))
fig.update_traces(hovertemplate = 'Вид: %{x}<br>Событий: %{y}')
fig.show()

Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы - 117328 раз, посещение страницы с предложениями товара производилось 46333 раза, помещение товара в корзину - 42303 раза, оплата товара - 33918 раз, меньше всего совершено посещений страницы с инструкцией по работе приложения - 1005 раз.

Сколько пользователей совершали каждое из событий?

Создадим таблицу с количеством пользователей по видам событий, при этом, учитывая, что не все пользователи посещали главную страницу приложения, добавим в таблицу строку с общим количеством пользователей.

In [40]:
# создадим таблицу с количеством пользователей по видам событий 
event_users_count = df_new.groupby('event_name')\
                          .agg(user_count = ('user_id', 'nunique'))\
                          .sort_values(by = 'user_count',
                                       ascending = False)\
                          .reset_index()
event_users_count.loc[-1] = ['all_users', df_new['user_id'].nunique()]
event_users_count.index = event_users_count.index + 1
event_users_count = event_users_count.sort_index()
event_users_count
Out[40]:
event_name user_count
0 all_users 7534
1 main_screen 7419
2 offer_screen 4593
3 cart_screen 3734
4 payment_screen 3539
5 tutorial 840
In [41]:
# построим график количества пользователей по видам событий
fig = px.bar(event_users_count.query('event_name != "all_users"'),
             x = 'event_name',
             y = 'user_count',
             color = 'event_name')
fig.update_layout(title = 'Количество пользователей по видам событий',
                  xaxis_title = 'Вид события',
                  yaxis_title = 'Количество пользователей',
                  showlegend = False,
                  margin = dict(l = 0, r = 0, t = 70, b = 20))
fig.update_traces(hovertemplate = 'Вид: %{x}<br>Пользователей: %{y}')
fig.show()

Наибольшее количество пользователей посещали главную страницу приложения - 7419, посещали страницу с предложениями товара 4593 пользователя, помещали товар в корзину - 3734, оплачивали товар - 3539, меньше всего пользователей посещали страницу с инструкцией по работе приложения - 840.

In [42]:
# добавим в таблицу столбец с долей пользователей, которые хоть раз совершали событие
event_users_count['ratio'] = round(event_users_count['user_count'] / df_new['user_id'].nunique() * 100, 1)
event_users_count
Out[42]:
event_name user_count ratio
0 all_users 7534 100.0
1 main_screen 7419 98.5
2 offer_screen 4593 61.0
3 cart_screen 3734 49.6
4 payment_screen 3539 47.0
5 tutorial 840 11.1

Посещали главную страницу приложения 98,5% всех уникальных пользователей, 61,0% посещали страницу с предложениями товара, 49,6% помещали товар в корзину, 47,0% оплачивали товар, меньше всего пользователей посещали страницу с инструкцией по работе приложения - 11,1%.

В каком порядке происходят события?

События выстраиваются в последовательную цепочку в следующем порядке:

  • посещение главной страницы приложения;
  • посещение страницы с предложениями товара;
  • помещение товара в корзину;
  • оплата товара.

Посещение страницы с инструкцией по работе приложения при построении воронки событий учитывать не следует, так как это событие может происходить после каждого из перечисленных шагов либо являться первым посещением приложения.

In [43]:
# удалим строку с посещениями страницы с инструкцией по работе приложения
event_users_count = event_users_count.query('event_name != "tutorial"')
event_users_count
Out[43]:
event_name user_count ratio
0 all_users 7534 100.0
1 main_screen 7419 98.5
2 offer_screen 4593 61.0
3 cart_screen 3734 49.6
4 payment_screen 3539 47.0

Не все пользователи идут по ожидаемому пути, создадим таблицу с количеством пользователей по видам событий с учётом порядка действий.

In [44]:
# создадим таблицу со временем первого совершения каждого события
users = df_new.pivot_table(index = 'user_id', 
                           columns = 'event_name', 
                           values = 'event_time',
                           aggfunc = 'min')\
              .reset_index()
users.head()
Out[44]:
event_name user_id cart_screen main_screen offer_screen payment_screen tutorial
0 6888746892508752 NaT 2019-08-06 14:06:34 NaT NaT NaT
1 6909561520679493 2019-08-06 18:52:58 2019-08-06 18:52:54 2019-08-06 18:53:04 2019-08-06 18:52:58 NaT
2 6922444491712477 2019-08-04 14:19:40 2019-08-04 14:19:33 2019-08-04 14:19:46 2019-08-04 14:19:40 NaT
3 7435777799948366 NaT 2019-08-05 08:06:34 NaT NaT NaT
4 7702139951469979 2019-08-02 14:28:45 2019-08-01 04:29:54 2019-08-01 04:29:56 2019-08-02 14:28:45 NaT
In [45]:
# создадим таблицу с количеством пользователей по видам событий с учётом порядка действий
step_1 = ~users['main_screen'].isna()
step_2 = step_1 & (users['offer_screen'] >= users['main_screen'])
step_3 = step_2 & (users['cart_screen'] >= users['offer_screen'])
step_4 = step_3 & (users['payment_screen'] >= users['cart_screen'])
n_main_screen = users[step_1].shape[0]
n_offer_screen = users[step_2].shape[0]
n_cart_screen = users[step_3].shape[0]
n_payment_screen = users[step_4].shape[0]
data = {'event_name': ['main_screen', 'offer_screen', 'cart_screen', 'payment_screen'],
        'user_count_step': [n_main_screen, n_offer_screen, n_cart_screen, n_payment_screen]}
event_users_count_step = pd.DataFrame(data = data)
event_users_count_step.loc[-1] = ['all_users', df_new.query('user_id != @main_screen_isna_list')['user_id'].nunique()]
event_users_count_step.index = event_users_count_step.index + 1
event_users_count_step = event_users_count_step.sort_index()
event_users_count_step['ratio_step'] = round(event_users_count_step['user_count_step']\
                                             / df_new.query('user_id != @main_screen_isna_list')['user_id'].nunique() * 100, 1)
event_users_count_step
Out[45]:
event_name user_count_step ratio_step
0 all_users 7419 100.0
1 main_screen 7419 100.0
2 offer_screen 4202 56.6
3 cart_screen 1796 24.2
4 payment_screen 1360 18.3
In [46]:
# объединим таблицы с количеством пользователей по видам событий
event_users_count = event_users_count.merge(event_users_count_step,
                                            on = 'event_name')
event_users_count
Out[46]:
event_name user_count ratio user_count_step ratio_step
0 all_users 7534 100.0 7419 100.0
1 main_screen 7419 98.5 7419 100.0
2 offer_screen 4593 61.0 4202 56.6
3 cart_screen 3734 49.6 1796 24.2
4 payment_screen 3539 47.0 1360 18.3

Если учитывать порядок действий пользователей в приложении, то после посещения главной страницы 56,6% из них посетили страницу с предложениями товара, 24,2% поместили товар в корзину, 18,3% оплатили товар.

Посмотрим, на каком этапе пользователи чаще всего посещают страницу с инструкцией по работе приложения.

In [47]:
users[~users['tutorial'].isna() & (users['tutorial'] < users['main_screen'])].shape[0]
Out[47]:
787

787 пользователей из 840 (93,7%) посещали страницу с инструкцией по работе приложения перед первым визитом на главную страницу.

Какая доля пользователей проходит на следующий шаг воронки?

In [48]:
# добавим в таблицу столбцы с долей пользователей, проходящих на следующий шаг воронки (от числа пользователей на предыдущем) 
event_users_count['ratio_previos'] = round(event_users_count['user_count']\
                                           / event_users_count['user_count']\
                                           .shift(periods = 1,
                                                  axis = 0,
                                                  fill_value = event_users_count['user_count'][0]) * 100, 1)
event_users_count['ratio_step_previos'] = round(event_users_count['user_count_step']\
                                                / event_users_count['user_count_step']\
                                                .shift(periods = 1,
                                                       axis = 0,
                                                       fill_value = event_users_count['user_count_step'][0]) * 100, 1)
event_users_count
Out[48]:
event_name user_count ratio user_count_step ratio_step ratio_previos ratio_step_previos
0 all_users 7534 100.0 7419 100.0 100.0 100.0
1 main_screen 7419 98.5 7419 100.0 98.5 100.0
2 offer_screen 4593 61.0 4202 56.6 61.9 56.6
3 cart_screen 3734 49.6 1796 24.2 81.3 42.7
4 payment_screen 3539 47.0 1360 18.3 94.8 75.7
In [49]:
# построим воронку событий
fig = go.Figure()
fig.add_trace(go.Funnel(x = event_users_count['user_count'],
                        y = event_users_count['event_name'],
                        textinfo = 'value + percent initial + percent previous',
                        hoverinfo = 'x + y + percent initial + percent previous'))
fig.update_layout(title = 'Воронка событий в мобильном приложении',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.show()
In [50]:
# построим воронку событий с учетом порядка действий
fig = go.Figure()
fig.add_trace(go.Funnel(x = event_users_count.query('event_name != "all_users"')['user_count_step'],
                        y = event_users_count.query('event_name != "all_users"')['event_name'],
                        textinfo = 'value + percent initial + percent previous',
                        hoverinfo = 'x + y + percent initial + percent previous'))
fig.update_layout(title = 'Воронка событий в мобильном приложении с учетом порядка действий',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.show()

Если не учитывать порядок действий, то посещали главную страницу приложения 98,5% пользователей, 61,9% от них посетили страницу с предложениями товара, 81,3% от них поместили товар в корзину, 94,8% от них оплатили товар.
Если учесть порядок действий, то из всех пользователей, посетивших главную страницу приложения, 56,6% затем посетили страницу с предложениями товара, 42,7% из них затем поместили товар в корзину, 75,7% из них затем оплатили товар.

На каком шаге теряется больше всего пользователей?

Если не учитывать порядок действий, то наибольшее снижение количества пользователей при переходе с главной страницы приложения на страницу с предложениями товара - 38,1%, наименьшее - на шаге прехода из корзины с товаром к его оплате - 5,2%.
Если учесть порядок действий, то больше всего пользователей теряется при переходе со страницы с предложениями товара к корзине с товаром - 57,3%, меньше всего - на шаге прехода из корзины с товаром к его оплате - 24,3%.

Какая доля пользователей доходит от первого события до оплаты?

In [51]:
# посчитаем долю пользователей, которая доходит от первого события до оплаты
ratio_main_payment = round(event_users_count.query('event_name == "payment_screen"')['user_count'].sum()\
                           / event_users_count.query('event_name == "main_screen"')['user_count'].sum() * 100, 1)
ratio_main_payment
Out[51]:
47.7
In [52]:
# посчитаем долю пользователей, которая доходит от первого события до оплаты, с учетом порядка действий
ratio_main_payment_step = round(event_users_count.query('event_name == "payment_screen"')['user_count_step'].sum()\
                                / event_users_count.query('event_name == "main_screen"')['user_count_step'].sum() * 100, 1)
ratio_main_payment_step
Out[52]:
18.3

Из всех пользователей, посетивших главную страницу приложения, 47,7% произвели оплату товара.
Всю последовательную цепочку событий от посещения главной страницы до оплаты товара прошли 18,3% пользователей.

Вывод

Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы - 117328 раз, посещение страницы с предложениями товара производилось 46333 раза, помещение товара в корзину - 42303 раза, оплата товара - 33918 раз, меньше всего совершено посещений страницы с инструкцией по работе приложения - 1005 раз.

Наибольшее количество пользователей посещали главную страницу приложения - 7419 (98,5%), посещали страницу с предложениями товара 4593 пользователя (61,0%), помещали товар в корзину - 3734 (49,6%), оплачивали товар - 3539 (47,0%), меньше всего пользователей посещали страницу с инструкцией по работе приложения - 840 (11,1%).

События выстраиваются в последовательную цепочку в следующем порядке:

  • посещение главной страницы приложения;
  • посещение страницы с предложениями товара;
  • помещение товара в корзину;
  • оплата товара.

Посещение страницы с инструкцией по работе приложения при построении воронки событий учитывать не следует, так как это событие может происходить после каждого из перечисленных шагов либо являться первым посещением приложения. Во время дальнейшего анализа установлено, что 787 пользователей из 840 (93,7%) посещали страницу с инструкцией по работе приложения перед первым визитом на главную страницу.

Если не учитывать порядок действий пользователей, то:

  • посещали главную страницу приложения 98,5% пользователей, 61,9% от них посетили страницу с предложениями товара, 81,3% от них поместили товар в корзину, 94,8% от них оплатили товар;
  • наибольшее снижение количества пользователей при переходе с главной страницы приложения на страницу с предложениями товара - 38,1%, наименьшее - на шаге прехода из корзины с товаром к его оплате - 5,2%;
  • из всех пользователей, посетивших главную страницу приложения, 47,7% произвели оплату товара.

Если учитывать порядок действий пользователей в приложении, то:

  • после посещения главной страницы 56,6% из них посетили страницу с предложениями товара, 24,2% поместили товар в корзину, 18,3% оплатили товар;
  • из всех пользователей, посетивших главную страницу приложения, 56,6% затем посетили страницу с предложениями товара, 42,7% из них затем поместили товар в корзину, 75,7% из них затем оплатили товар;
  • больше всего пользователей теряется при переходе со страницы с предложениями товара к корзине с товаром - 57,3%, меньше всего - на шаге прехода из корзины с товаром к его оплате - 24,3%;
  • всю последовательную цепочку событий от посещения главной страницы до оплаты товара прошли 18,3% пользователей.

Изучение результатов эксперимента

Сколько пользователей в каждой контрольной и экспериментальной группе?

In [53]:
# проверим, имеются ли пользователи, попавшие в разные контрольные и экспериментальную группы
(df_new.groupby('user_id')['group'].nunique() > 1).sum()
Out[53]:
0

Во всех трех группах находятся только уникальные пользователи.

In [54]:
# посмотрим на распределение пользователей по группам
event_users_group_new
Out[54]:
group user_count
0 246 2484
1 247 2513
2 248 2537
In [55]:
# построим график соотношения пользователей по группам
fig = go.Figure()
fig.add_trace(go.Pie(labels = event_users_group_new['group'],
                     values = event_users_group_new['user_count'],
                     textinfo = 'value + percent',
                     hole = 0.35))
fig.update_layout(annotations = [dict(text = 'Соотношение<br>пользователей<br>по группам',
                                      font_size = 20,
                                      showarrow = False)],
                  legend = dict(x = 0.7,
                                font_size = 16),
                  margin = dict(l = 0, r = 0, t = 20, b = 20))
fig.update_traces(textposition = 'inside',
                  textfont_size = 16)
fig.show()

Пользователи между группами распределены практически равномерно: в 246 группе 2484 пользователя (33,0% от их общего количества), в 247 группе 2513 пользователей (33,3% от их общего количества), в 248 группе 2537 пользователей (33,7% от их общего количества).

Находят ли статистические критерии разницу между контрольными группами?

Сначала создадим таблицу с количеством пользователей, долей пользователей, которые хоть раз совершали событие, долей пользователей, проходящих на следующий шаг воронки, по видам событий и группам

In [56]:
# создадим функцию для формирования таблицы
def event_group(group):
    result = df_new.query('group == @group & event_name != "tutorial"')\
                   .groupby('event_name')\
                   .agg(users = ('user_id', 'nunique'))\
                   .sort_values(by = 'users',
                                ascending = False)\
                   .reset_index()
    result.loc[-1] = ['all_users', df_new.query('group == @group')['user_id'].nunique()]
    result.index = result.index + 1
    result = result.sort_index()
    result['ratio'] = round(result['users'] / df_new.query('group == @group')['user_id'].nunique(), 3)
    result['ratio_previos'] = round(result['users']\
                                   / result['users'].shift(periods = 1,
                                                           axis = 0,
                                                           fill_value = result['users'][0]), 3)
    return result
In [57]:
# создадим таблицу
event_group_246 = event_group(246)
event_group_247 = event_group(247)
event_group_248 = event_group(248)
event_group = event_group_246.merge(event_group_247,
                                    on = 'event_name',
                                    suffixes = (None, '_247'))
event_group = event_group.merge(event_group_248,
                                on = 'event_name',
                                suffixes = ('_246', '_248'))
event_group
Out[57]:
event_name users_246 ratio_246 ratio_previos_246 users_247 ratio_247 ratio_previos_247 users_248 ratio_248 ratio_previos_248
0 all_users 2484 1.000 1.000 2513 1.000 1.000 2537 1.000 1.000
1 main_screen 2450 0.986 0.986 2476 0.985 0.985 2493 0.983 0.983
2 offer_screen 1542 0.621 0.629 1520 0.605 0.614 1531 0.603 0.614
3 cart_screen 1266 0.510 0.821 1238 0.493 0.814 1230 0.485 0.803
4 payment_screen 1200 0.483 0.948 1158 0.461 0.935 1181 0.466 0.960
In [58]:
# построим воронку событий в разрезе групп
fig = go.Figure()
fig.add_trace(go.Funnel(x = event_group['users_246'],
                        y = event_group['event_name'],
                        textinfo = 'value + percent initial + percent previous',
                        hoverinfo = 'name + x + y + percent initial + percent previous',
                        name = '246'))
fig.add_trace(go.Funnel(x = event_group['users_247'],
                        y = event_group['event_name'],
                        textinfo = 'value + percent initial + percent previous',
                        hoverinfo = 'name + x + y + percent initial + percent previous',
                        name = '247'))
fig.add_trace(go.Funnel(x = event_group['users_248'],
                        y = event_group['event_name'],
                        textinfo = 'value + percent initial + percent previous',
                        hoverinfo = 'name + x + y + percent initial + percent previous',
                        name = '248'))
fig.update_layout(title = 'Воронка событий в мобильном приложении в разрезе групп',
                  margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.show()

Визуально, на первый взгляд, кажется, что группа 246 немного успешнее, чем другие, необходимо проверить это статистическими методами. Однако, в первую очередь нужно установить, есть ли различие между контрольными группами по размеру. В абсолютном выражении разница в количестве пользователей - 29, определим, является ли эта разница статистически значимой. Для этого проверим гипотезу о равенстве пропорций двух генеральных совокупностей при помощи z-критерия.

Критический уровень статистической значимости определим в 5%.

In [59]:
# зададим критический уровень статистической значимости
alpha = .05

Сформулируем гипотезы:
H0 - различий в долях двух контрольных групп нет;
H1 - различия в долях двух контрольных групп есть.

In [60]:
# создадим функцию для проверки гипотезы
def z_test_proportion(test_group_1, test_group_2):
    successes = np.array([event_group.query('event_name == "all_users"')[str('users_') + str(test_group_1)].sum(),
                          event_group.query('event_name == "all_users"')[str('users_') + str(test_group_2)].sum()])
    trials = np.array([df_new['user_id'].nunique(), df_new['user_id'].nunique()])
    p1 = successes[0] / trials[0]
    p2 = successes[1] / trials[1]
    p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
    difference = p1 - p2
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
    distr = stats.norm(0, 1)
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    print('p-значение: ', round(p_value, 4))
    if (p_value < alpha):
        print('Отвергаем нулевую гипотезу: между долями есть значимые различия.')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.')
In [61]:
# проверим гипотезу
z_test_proportion(246, 247)
p-значение:  0.6158
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Полученное p-значение оказалось больше уровня значимости в 5%, следовательно нулевую гипотезу не отвергаем: различий в долях двух контрольных групп нет.

Будет ли отличие между контрольными группами статистически достоверным?

Для определения наличия отличий между контрольными группами проверим гипотезу о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий.

Критический уровень статистической значимости определим в 5%. Учитывая, что будет проведено 4 попарных сравнения, к уровню статистической значимости применим поправку Бонферрони.
Сформулируем гипотезы:
H0 - различий в долях двух контрольных групп нет;
H1 - различия в долях двух контрольных групп есть.

In [62]:
# применим поправку Бонферрони к уровню значимости
bonferroni_alpha = alpha / 4
In [63]:
# создадим функцию для проверки гипотезы
def z_test(test_group_1, test_group_2, event_name):
    successes = np.array([event_group.query('event_name == @event_name')[str('users_') + str(test_group_1)].sum(),
                          event_group.query('event_name == @event_name')[str('users_') + str(test_group_2)].sum()])
    trials = np.array([event_group.query('event_name == "all_users"')[str('users_') + str(test_group_1)].sum(),
                       event_group.query('event_name == "all_users"')[str('users_') + str(test_group_2)].sum()])
    p1 = successes[0] / trials[0]
    p2 = successes[1] / trials[1]
    p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
    difference = p1 - p2
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
    distr = stats.norm(0, 1)
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    print('p-значение: ', round(p_value, 4))
    if (p_value < bonferroni_alpha):
        print('Отвергаем нулевую гипотезу: между долями есть значимые различия.')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.')
In [64]:
# проверим гипотезу для контрольных групп 246 и 247
print('Проверка гипотезы для контрольных групп 246 и 247')   
print()
print('Уровень статистической значимости: ', bonferroni_alpha)   
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
    print('Шаг цепочки событий: ', name)
    z_test(246, 247, name)
    print()   
Проверка гипотезы для контрольных групп 246 и 247

Уровень статистической значимости:  0.0125

Шаг цепочки событий:  main_screen
p-значение:  0.7571
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  offer_screen
p-значение:  0.2481
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  cart_screen
p-значение:  0.2288
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  payment_screen
p-значение:  0.1146
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях двух контрольных групп нет.

Корректно ли работает разбиение на группы?

Критерии успешного A/A-теста:

  • различие в количестве пользователей в разных группах не является статистически значимым;
  • для всех групп фиксируют и отправляют в системы аналитики данные об одном и том же;
  • различие ключевых метрик по группам не имеет статистической значимости;
  • попавший в одну из групп пользователь остаётся в этой группе до конца теста.

Проведенный анализ говорит о том, что все критерии успешного А/А теста в исследовании соблюдены,таким образом разбиение на контрольные группы корректно, можно приступать к проведению А/В теста.

Работа с экспериментальной группой.

In [65]:
# добавим в таблицу столбец с количеством пользователей объединенной контрольной группы
event_group['users_union'] = event_group['users_246'] + event_group['users_247']
event_group
Out[65]:
event_name users_246 ratio_246 ratio_previos_246 users_247 ratio_247 ratio_previos_247 users_248 ratio_248 ratio_previos_248 users_union
0 all_users 2484 1.000 1.000 2513 1.000 1.000 2537 1.000 1.000 4997
1 main_screen 2450 0.986 0.986 2476 0.985 0.985 2493 0.983 0.983 4926
2 offer_screen 1542 0.621 0.629 1520 0.605 0.614 1531 0.603 0.614 3062
3 cart_screen 1266 0.510 0.821 1238 0.493 0.814 1230 0.485 0.803 2504
4 payment_screen 1200 0.483 0.948 1158 0.461 0.935 1181 0.466 0.960 2358

Для определения наличия отличий между контрольными и экспериментальной группами проверим гипотезу о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.

Критический уровень статистической значимости определим в 5%. Учитывая, что для каждой из контрольных групп, а также объединенной контрольной группы будет проведено по 4 попарных сравнения с экспериментальной группой, к уровню статистической значимости применим поправку Бонферрони.
Сформулируем гипотезы:
H0 - различий в долях контрольной и экспериментальной групп нет;
H1 - различия в долях контрольной и экспериментальной групп есть.

Проверим гипотезу о различиях в долях групп 246 и 248.

In [66]:
# проверим гипотезу для групп 246 и 248
print('Проверка гипотезы для групп 246 и 248')   
print()
print('Уровень статистической значимости: ', bonferroni_alpha)   
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
    print('Шаг цепочки событий: ', name)
    z_test(246, 248, name)
    print()   
Проверка гипотезы для групп 246 и 248

Уровень статистической значимости:  0.0125

Шаг цепочки событий:  main_screen
p-значение:  0.295
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  offer_screen
p-значение:  0.2084
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  cart_screen
p-значение:  0.0784
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  payment_screen
p-значение:  0.2123
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях групп 246 и 248 нет.

Проверим гипотезу о различиях в долях групп 247 и 248.

In [67]:
# проверим гипотезу для групп 247 и 248
print('Проверка гипотезы для групп 247 и 248')   
print()
print('Уровень статистической значимости: ', bonferroni_alpha)   
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
    print('Шаг цепочки событий: ', name)
    z_test(247, 248, name)
    print()   
Проверка гипотезы для групп 247 и 248

Уровень статистической значимости:  0.0125

Шаг цепочки событий:  main_screen
p-значение:  0.4587
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  offer_screen
p-значение:  0.9198
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  cart_screen
p-значение:  0.5786
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  payment_screen
p-значение:  0.7373
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях групп 247 и 248 нет.

Проверим гипотезу о различиях в долях группы 248 и объединенной группы.

In [68]:
# проверим гипотезу для группы 248 и объединенной группы
print('Проверка гипотезы для группы 248 и объединенной группы')   
print()
print('Уровень статистической значимости: ', bonferroni_alpha)   
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
    print('Шаг цепочки событий: ', name)
    z_test('union', 248, name)
    print()   
Проверка гипотезы для группы 248 и объединенной группы

Уровень статистической значимости:  0.0125

Шаг цепочки событий:  main_screen
p-значение:  0.2942
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  offer_screen
p-значение:  0.4343
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  cart_screen
p-значение:  0.1818
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Шаг цепочки событий:  payment_screen
p-значение:  0.6004
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.

Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях группы 248 и объединенной группы нет.

Ни одна из проведенных проверок не показала значимых различий между долями, поэтому отсутствуют основания считать, что изменение шрифта в мобильном приложении влияет на поведение пользователей.

Сколько проверок статистических гипотез было сделано?

Всего при проведении А/В теста было сделано 12 проверок статистических гипотез: для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.

Целесообразно ли изменить уровень статистической значимости?

При проверке статистических гипотез был принят критический уровень статистической значимости в 5% с учетом поправки на множественные сравнения - это вероятность ошибочно отвергнуть нулевую гипотезу. При увеличении статистической значимости увеличится критический диапазон, при попадании в который нулевая гипотеза будет отвергнута, — возрастет вероятность ошибки первого рода (будет больше ложных срабатываний), вместе с тем уменьшится вероятность ошибки второго рода - ошибочно принять нулевую гипотезу при верной альтернативной. В проведенном исследовании при уровне значимости, например в 10%, с учетом поправки на множественные сравнения все результаты проверки статистических гипотез оказались бы неизменными. Таким образом уровень статистической значимости менять нецелесообразно.

Вывод

Перед проведением А/В теста был проведен А/А тест. Критерии успешного A/A-теста:

  • различие в количестве пользователей в разных группах не является статистически значимым;
  • для всех групп фиксируют и отправляют в системы аналитики данные об одном и том же;
  • различие ключевых метрик по группам не имеет статистической значимости;
  • попавший в одну из групп пользователь остаётся в этой группе до конца теста.

Во всех трех группах находятся только уникальные пользователи. При этом, пользователи между группами распределены практически равномерно: в 246 группе 2484 пользователя (33,0% от их общего количества), в 247 группе 2513 пользователей (33,3% от их общего количества), в 248 группе 2537 пользователей (33,7% от их общего количества).

Для того, чтобы определить, является ли различие в количестве пользователей в разных группах статистически значимым, была проверена гипотеза о равенстве пропорций двух генеральных совокупностей при помощи z-критерия, критический уровень статистической значимости при этом был определен в размере в 5%. Полученное p-значение оказалось больше установленного уровня значимости, что позволило не отвергнуть нулевую гипотезу и сделать вывод об отсутствии различий в долях двух контрольных групп.

Кроме этого, для определения наличия отличий между контрольными группами была проверена гипотеза о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий, критический уровень статистической значимости при этом был определен в размере 5% (учитывая, что проведено 4 попарных сравнения, к уровню статистической значимости применили поправку Бонферрони). Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше установленного уровня значимости с учетом поправки на множественные сравнения, что позволило не отвергнуть нулевую гипотезу и сделать вывод об отсутствии различий в долях двух контрольных групп.

Таким образом, исходя из проведенного анализа, все критерии успешного А/А теста в исследовании были соблюдены, разбиение на контрольные группы произведено корректно, что позволило приступить к проведению А/В теста.

Для определения наличия отличий между контрольными и экспериментальной группами (А/В тест) была проверена гипотеза о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной, критический уровень статистической значимости при этом был определен в размере 5% (учитывая, что проведено 4 попарных сравнения, к уровню статистической значимости применили поправку Бонферрони). Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше установленного уровня значимости с учетом поправки на множественные сравнения, что позволило не отвергнуть нулевую гипотезу и сделать вывод об отсутствии различий в долях каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.

Так как ни одна из проведенных проверок не показала значимых различий между долями, отсутствуют основания считать, что изменение шрифта в мобильном приложении влияет на поведение пользователей.

Всего при проведении А/В теста было сделано 12 проверок статистических гипотез: для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.

При проверке статистических гипотез был принят критический уровень статистической значимости в 5% с учетом поправки на множественные сравнения - это вероятность ошибочно отвергнуть нулевую гипотезу. При увеличении статистической значимости увеличится критический диапазон, при попадании в который нулевая гипотеза будет отвергнута, — возрастет вероятность ошибки первого рода (будет больше ложных срабатываний), вместе с тем уменьшится вероятность ошибки второго рода - ошибочно принять нулевую гипотезу при верной альтернативной. В проведенном исследовании при уровне значимости, например в 10%, с учетом поправки на множественные сравнения все результаты проверки статистических гипотез оказались бы неизменными. Таким образом уровень статистической значимости менять нецелесообразно.

Общий вывод

Всего имеется информация о 243713 событиях, которые совершил 7551 уникальный пользователь, среднее количество событий на одного пользователя - 20.

Всего в логе данные за период с 25 июля по 7 августа 2019 года (14 дней), однако информация до 31 июля 2019 года включительно неполная, таким образом актуальный период для исследования - с 1 по 7 августа 2019 года (7 дней). Отбросив часть данных, всего для анализа в актуальном периоде осталось 240887 событий и 7534 уникальных пользователя, "потеряно" 1,16% событий и 0,23% пользователей.

Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения (117328 раз, совершенных 7419 пользователями, что составляет 98,5% от их общего количества), посещение страницы с предложениями товара (46333 раза, совершенных 4593 пользователями, что составляет 61,0% от их общего количества), помещение товара в корзину (42303 раза, совершенных 3734 пользователями, что составляет 49,6% от их общего количества), оплата товара (33918 раз, совершенных 3539 пользователями, что составляет 47,0% от их общего количества) и посещение страницы с инструкцией по работе приложения (1005 раз, совершенных 840 пользователями, что составляет 11,1% от их общего количества).

События, совершенные пользователями, выстраиваются в последовательную цепочку в следующем порядке:

  • посещение главной страницы приложения;
  • посещение страницы с предложениями товара;
  • помещение товара в корзину;
  • оплата товара.

Если не учитывать порядок действий пользователей в приложении, то наибольшее снижение количества пользователей происходит на этапе посещения страницы с предложениями товара по сравнению с посещением главной страницы - 38,1%. При последовательном движении по цепочке событий больше всего пользователей теряется при переходе со страницы с предложениями товара к корзине с товаром - 57,3%.

Из всех пользователей, посетивших главную страницу приложения, 47,7% произвели оплату товара, а всю последовательную цепочку событий от посещения главной страницы до оплаты товара прошли только 18,3% пользователей.

Для ответа на вопрос о влиянии изменения шрифта в мобильном приложении на поведение пользователей был проведен эксперимент, при этом пользователи были распределены на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Контрольные группы приняли участие в проведении успешного A/A-теста, в результате которого установлено:

  • различие в количестве пользователей в разных группах не является статистически значимым;
  • различие для каждого шага цепочки событий по группам не имеет статистической значимости;
  • в группах находятся только уникальные пользователи;
  • разбиение на группы произведено корректно.

Успешный А/А тест позволил приступить к проведению А/В теста, в котором экспериментальная группа сравнивалась по каждому шагу цепочки событий с каждой из контрольных групп, а также объединенной контрольной группой. Во время проведения эксперимента было сделано 12 проверок статистических гипотез, при этом ни одна из проведенных проверок не показала наличие статистически значимых различий, вследствие чего отсутствуют основания считать, что изменение шрифта в мобильном приложении влияет на поведение пользователей.

Полученные при проверке гипотез во время А/В теста уровни p-значения во всех случаях оказались больше установленного уровня статистической значимости в 5% с учетом поправки на множественные сравнения, при этом увеличение установленного уровня значимости, например до 10%, никак не повлияло бы на результаты эксперимента, выводы по всем проверкам статистических гипотез оказались бы неизменными.

Рекомендации

  1. При подготовке к проведению экспериментов внимательнее подходить к сбору необходимых данных.
  2. Проверить удобство перехода на страницу с инструкцией по работе приложения с каждой его страницы.
  3. Проверить комфортность перехода со станицы с предложениями товара к корзине с товаром.
  4. Не менять шрифты в приложении, так как это не влияет на поведение пользователей, либо рассмотреть возможность более серьезного изменения дизайна приложения.